<?PHP
#
#   FILE:  SearchParameterSetEditingUI.php
#
#   Part of the Metavus digital collections platform
#   Copyright 2016-2021 Edward Almasy and Internet Scout Research Group
#   http://metavus.net
#
# @scout:phpstan

namespace Metavus;

use Exception;
use ScoutLib\HtmlOptionList;
use ScoutLib\SearchParameterSet;
use ScoutLib\StdLib;

/**
* Class to create a user interface for editing SearchParameterSets.
*/
class SearchParameterSetEditingUI
{
    /**
    * Create a UI for specifing edits to SearchParameterSets.
    * @param string $FormFieldName HTML 'name' to use for <input>
    *   elements created by the UI.  If this UI is incorporated into a
    *   form containing other input elements, they must have names
    *   that differ from this one.
    * @param SearchParameterSet $SearchParams SearchParameterSet to display
    *  (OPTIONAL, uses an empty set if unspecified)
    */
    public function __construct(string $FormFieldName, SearchParameterSet $SearchParams = null)
    {
        $this->EditFormName = $FormFieldName;

        if ($SearchParams !== null) {
            $this->SearchParams = $SearchParams;
        } else {
            $this->SearchParams = new SearchParameterSet();
        }

        # get the list of fields that are allowed in searches for all schemas
        $this->MFields = [];
        $this->AllSchemas = MetadataSchema::getAllSchemas();
        foreach ($this->AllSchemas as $ScId => $Schema) {
            $this->AllowedSchemaIds[] = $ScId;

            $Fields = $Schema->getFields(
                null,
                MetadataSchema::MDFORDER_ALPHABETICAL
            );
            foreach ($Fields as $Field) {
                $this->MFields[] = $Field;
            }
        }

        $this->Factories = [];
    }

    /**
     * Get/set the list of allowed SchemaIds for this search.
     * @param array $NewValue Updated array of SchemaIds to allow (OPTIONAL)
     * @return array List of allowed SchemaIDs
     */
    public function allowedSchemaIds(array $NewValue = null) : array
    {
        if ($NewValue !== null) {
            foreach ($NewValue as $SchemaId) {
                if (!MetadataSchema::schemaExistsWithId($SchemaId)) {
                    throw new Exception(
                        "Invalid Schema Id provided: ".$SchemaId
                    );
                }
            }

            $this->AllowedSchemaIds = $NewValue;
        }

        return $this->AllowedSchemaIds;
    }

    /**
    * Display editing form elements enclosed in a <table>.  Note that
    * it still must be wrapped in a <form> that has a submit button.
    * @param string $TableId HTML identifier to use (OPTIONAL, default
    *   NULL).
    * @param string $TableStyle CSS class to attach for this table
    *   (OPTIONAL, default NULL).
    */
    public function displayAsTable(string $TableId = null, string $TableStyle = null)
    {
        print('<table id="'.defaulthtmlentities($TableId).'" '
              .'class="'.defaulthtmlentities($TableStyle).'" '
              .'style="width: 100%">');
        $this->displayAsRows();
        print('</table>');
    }

    /**
    * Display the table rows for the editing form, without the
    * surrounding <table> tags.
    */
    public function displayAsRows()
    {

        $Fields = $this->flattenSearchParams(
            $this->SearchParams
        );

        # make sure the necessary javascript is required
        $GLOBALS["AF"]->RequireUIFile("jquery-ui.js");
        $GLOBALS["AF"]->RequireUIFile("CW-QuickSearch.js");
        $GLOBALS["AF"]->RequireUIFile("SearchParameterSetEditingUI.js");

        # note that all of the fields we create for these rows will be named
        # $this->EditFormName.'[]' , combining them all into an array of results per
        #   http://php.net/manual/en/faq.html.php#faq.html.arrays

        # css classes required by our javascript are logic_row
        # field-row, and field-value-edit

        # field types where a leading = should be stripped before display
        $StripEqualsTypes = [
            MetadataSchema::MDFTYPE_CONTROLLEDNAME,
            MetadataSchema::MDFTYPE_OPTION,
            MetadataSchema::MDFTYPE_USER,
            MetadataSchema::MDFTYPE_FLAG,
        ];

        $Depth = 0;

        foreach ($Fields as $FieldRow) {
            if (is_string($FieldRow) && $FieldRow == "(") {
                $Depth++;
                print('<tr><td colspan=2 style="padding-left: 2em;">'
                      .'<input type="hidden" name="'.$this->EditFormName.'[]" '
                      .'value="X-BEGIN-SUBGROUP-X"/>'
                      .'<table class="mv-speui-subgroup">');
            } elseif (is_string($FieldRow) && $FieldRow == ")") {
                $Depth--;
                $this->printTemplateRow();
                print('<input type="hidden" name="'.$this->EditFormName.'[]" '
                    .'value="X-END-SUBGROUP-X"/></table></td></tr>');
            } elseif (is_array($FieldRow) && isset($FieldRow["Logic"])) {
                print('<tr class="logic_row '.$this->EditFormName.'">'
                      .'<td colspan="3">'
                      .($Depth == 0 ? 'Top-Level Logic: ' : 'Subgroup with '));

                $ListName = $this->EditFormName."[]";
                $Options = ["AND" => "AND", "OR" => "OR"];
                $SelectedValue = $FieldRow["Logic"];

                $OptList = new HtmlOptionList($ListName, $Options, $SelectedValue);
                $OptList->classForList("logic");
                $OptList->printHtml();

                print (($Depth > 0 ? ' Logic' : '').'</td></tr>');
            } elseif (is_array($FieldRow) && isset($FieldRow["FieldId"])) {
                $FieldId = $FieldRow["FieldId"];
                $Values = $FieldRow["Values"];

                foreach ($Values as $CurVal) {
                    print('<tr class="field-row '.$this->EditFormName.'""
                                ." style="white-space: nowrap;">'
                          ."<td><span class=\"btn btn-primary btn-sm "
                          ."mv-speui-delete\">X</span>"
                          ."</td><td>");

                    # for selectable fields, we need to generate all the
                    # html elements that we might need and then depend on
                    # javascript to display only those that are relevant

                    # each field will have four elements

                    # 1. a field selector
                    $this->printFieldSelector($FieldId);

                    # 2. a value selector (for option and flag values)
                    $this->printValueSelector($FieldId, $CurVal);

                    $SearchText = $CurVal;
                    if ($FieldId !== "X-KEYWORD-X") {
                        $Field = new MetadataField($FieldId);
                        if (in_array($Field->type(), $StripEqualsTypes)) {
                            $SearchText = (StdLib::strpos($CurVal, "=") === 0) ?
                                    StdLib::substr($CurVal, 1) : $CurVal;
                        }
                    }

                    # 3. a text entry
                    print('<input type="text" class="field-value-edit" '
                          .'name="'.$this->EditFormName.'[]" '
                          .'placeholder="(search terms)" '
                          .'value="'.defaulthtmlentities($SearchText).'">');

                    # 4. an ajax search box
                    $this->printQuicksearch($FieldId, $SearchText);

                    print("</td></tr>");
                }
            }
        }

        # add a template row, used for adding new fields
        $this->printTemplateRow();
    }

    /**
    * Extract values from a dynamics field edit/modification form.
    * @return SearchParameterSet extracted from post data.  If POST
    *   contains no data, an empty SearchParameterSet will be returned.
    */
    public function getValuesFromFormData()
    {
        if (!isset($_POST[$this->EditFormName])) {
            $Result = new SearchParameterSet();
        } else {
            # set up our result
            $GroupStack = [];
            array_push($GroupStack, new SearchParameterSet());

            # extract the array of data associated with our EditFormName
            $FormData = $_POST[$this->EditFormName];

            # extract and set the search logic, which is always the first
            # element in the HTML that we generate
            $Logic = array_shift($FormData);
            end($GroupStack)->Logic($Logic);

            while (count($FormData)) {
                # first element of each row is a field id
                $FieldId = array_shift($FormData);

                if ($FieldId == "X-BEGIN-SUBGROUP-X") {
                    # add a new subgroup to our stack of subgroups
                    array_push($GroupStack, new SearchParameterSet());
                    # extract and set the search logic
                    $Logic = array_shift($FormData);
                    end($GroupStack)->Logic($Logic);
                } elseif ($FieldId == "X-END-SUBGROUP-X") {
                    $Subgroup = array_pop($GroupStack);
                    $Tgt = end($GroupStack);
                    if ($Tgt === false) {
                        throw new Exception(
                            "Attempt to add set to an empty subgroup."
                        );
                    }
                    $Tgt->AddSet($Subgroup);
                } else {
                    # for selectable fields, we'll have all possible
                    # elements and will need to grab the correct ones for
                    # the currently selected field
                    $SelectVal = array_shift($FormData);
                    $TextVal   = array_shift($FormData);
                    $SearchVal = array_shift($FormData);

                    if ($FieldId == "X-KEYWORD-X") {
                        $Val = $TextVal;
                        $Field = null;

                        if (strlen($TextVal) == 0) {
                            continue;
                        }
                    } else {
                        $Field = new MetadataField($FieldId);

                        # make sure we have factories for field types that need them
                        switch ($Field->type()) {
                            case MetadataSchema::MDFTYPE_TREE:
                            case MetadataSchema::MDFTYPE_CONTROLLEDNAME:
                            case MetadataSchema::MDFTYPE_OPTION:
                                if (!isset($this->Factories[$FieldId])) {
                                    $this->Factories[$FieldId] = $Field->getFactory();
                                }
                                break;

                            default:
                                break;
                        }

                        # verify that we actually have a value for our selected field
                        switch ($Field->type()) {
                            case MetadataSchema::MDFTYPE_PARAGRAPH:
                            case MetadataSchema::MDFTYPE_URL:
                            case MetadataSchema::MDFTYPE_TEXT:
                            case MetadataSchema::MDFTYPE_NUMBER:
                            case MetadataSchema::MDFTYPE_DATE:
                            case MetadataSchema::MDFTYPE_TIMESTAMP:
                            case MetadataSchema::MDFTYPE_USER:
                                # if we have no value for this field, skip displaying it
                                if (strlen($TextVal) == 0) {
                                    continue 2;
                                }
                                break;

                            case MetadataSchema::MDFTYPE_TREE:
                            case MetadataSchema::MDFTYPE_CONTROLLEDNAME:
                                # if we have no value for this field, skip displaying it
                                if (strlen($SearchVal) == 0) {
                                    continue 2;
                                }
                                break;

                            # no need to check the types where there's
                            # a SelectVal, as that cannot be left empty
                            default:
                                break;
                        }

                        # extract the value for our field
                        switch ($Field->type()) {
                            case MetadataSchema::MDFTYPE_PARAGRAPH:
                            case MetadataSchema::MDFTYPE_URL:
                            case MetadataSchema::MDFTYPE_TEXT:
                            case MetadataSchema::MDFTYPE_NUMBER:
                            case MetadataSchema::MDFTYPE_DATE:
                            case MetadataSchema::MDFTYPE_TIMESTAMP:
                            case MetadataSchema::MDFTYPE_IMAGE:
                            case MetadataSchema::MDFTYPE_FILE:
                                $Val = $TextVal;
                                break;

                            case MetadataSchema::MDFTYPE_USER:
                                $Val = "=".$TextVal;
                                break;

                            case MetadataSchema::MDFTYPE_TREE:
                                $Item = $this->Factories[$FieldId]->GetItem(
                                    $SearchVal
                                );

                                # for tree fields, use the same 'is X
                                # or a child of X' construction that we
                                # use when generating search facets
                                $Val = new SearchParameterSet();
                                $Val->logic("OR");
                                $Val->addParameter(
                                    [
                                        "=".$Item->Name(),
                                        "^".$Item->Name()." -- "
                                    ],
                                    $Field
                                );
                                break;

                            case MetadataSchema::MDFTYPE_CONTROLLEDNAME:
                                $Item = $this->Factories[$FieldId]->GetItem(
                                    $SearchVal
                                );
                                $Val = "=".$Item->Name();
                                break;

                            case MetadataSchema::MDFTYPE_OPTION:
                                list($InputId, $InputVal) = explode("-", $SelectVal, 2);
                                $Item = $this->Factories[$FieldId]->GetItem(
                                    $InputVal
                                );
                                $Val = "=".$Item->Name();
                                break;

                            case MetadataSchema::MDFTYPE_FLAG:
                                list($InputId, $InputVal) = explode("-", $SelectVal, 2);
                                $Val = "=".$InputVal;
                                break;

                            default:
                                throw new Exception("Unsupported field type");
                        }
                    }

                    # add our value to the search parameters
                    $Tgt = end($GroupStack);
                    if ($Val instanceof SearchParameterSet) {
                        if ($Tgt === false) {
                            throw new Exception(
                                "Attempt to add subgroup to an empty set."
                            );
                        }
                        $Tgt->AddSet($Val);
                    } else {
                        if ($Tgt === false) {
                            throw new Exception(
                                "Attempt to add value without any search parameters."
                            );
                        }
                        $Tgt->AddParameter($Val, $Field);
                    }
                }
            }

            $Result = array_pop($GroupStack);
        }

        $this->SearchParams = $Result;
        return $Result;
    }

    /**
    * Get/Set search parameters.
    * @param SearchParameterSet|null $SearchParams New setting (OPTIONAL)
    * @return SearchParameterSet Current SearchParameterSet
    */
    public function searchParameters(SearchParameterSet $SearchParams = null): SearchParameterSet
    {
        if ($SearchParams !== null) {
            $this->SearchParams = clone $SearchParams;
        }

        return clone $this->SearchParams;
    }

    /**
    * Get/set the max number of characters a label of a field option list
    *        will be displayed.
    * @param int $NewValue Max length of a field option list's label. Use
    *       zero for no limit (OPTIONAL, default to no limit).
    *       If NULL is passed in, this function will not set a new max
    *       length of a field option list.
    * @return int Current maximum length of a field option list's label.
    *       Zero means no limit.
    */
    public function maxFieldLabelLength(int $NewValue = null): int
    {
        if (!is_null($NewValue)) {
            $this->MaxFieldLabelLength = $NewValue;
        }
        return $this->MaxFieldLabelLength;
    }

    /**
    * Get/set the max number of characters a label of a value option list
    *       will be displayed.
    * @param int $NewValue Max length of a field option list's label. Use
    *       zero for no limit (OPTIONAL, default to no limit).
    *       If NULL is passed in, this function will not set a new max
    *       length of a value option list.
    * @return int Current maximum length of a value option list's label.
    *       Zero means no limit.
    */
    public function maxValueLabelLength(int $NewValue = null): int
    {
        if (!is_null($NewValue)) {
            $this->MaxValueLabelLength = $NewValue;
        }
        return $this->MaxValueLabelLength;
    }


    # ---- PRIVATE INTERFACE -------------------------------------------------

    private $EditFormName;
    private $SearchParams;
    private $MFields;
    private $AllSchemas;
    private $AllowedSchemaIds = [];
    private $Factories;
    private $MaxFieldLabelLength = 0;
    private $MaxValueLabelLength = 0;

    /**
    * Convert a SearchParameterSet into a flat array that can be
    * easily iterated over when outputting HTML form elements.
    * @param SearchParameterSet $SearchParams Paramters to convert
    * @return array where each element is one of
    *   ["Logic" => LogicSetting]
    *   ["FieldId" => FieldValues]
    *   ["X-KEYWORD-X" => KeywordValues]
    *   "("   -- denoting the beginning of a subgroup
    *   ")"   -- denoting the end of a subgroup
    */
    private function flattenSearchParams(SearchParameterSet $SearchParams): array
    {
        $Result = [];

        $Result[] = ["Logic" => $SearchParams->logic()];

        $SearchStrings = $SearchParams->getSearchStrings();
        foreach ($SearchStrings as $FieldId => $Values) {
            $Result[] = [
                "FieldId" => $FieldId,
                "Values" => $Values
            ];
        }

        $KeywordStrings = $SearchParams->getKeywordSearchStrings();
        if (count($KeywordStrings)) {
            $Result[] = [
                "FieldId" => "X-KEYWORD-X",
                "Values" => $KeywordStrings
            ];
        }

        $Subgroups = $SearchParams->getSubgroups();
        if (count($Subgroups)) {
            foreach ($Subgroups as $Subgroup) {
                $Result[] = "(";
                $SubgroupItems = $this->flattenSearchParams($Subgroup);
                foreach ($SubgroupItems as $Item) {
                    $Result[] = $Item;
                }
                $Result[] = ")";
            }
        }

        return $Result;
    }

    /**
    * Print HTML elements for the field selector.
    * @param string|null $FieldId Currently selected field.
    */
    private function printFieldSelector($FieldId)
    {
        $ListName = $this->EditFormName."[]";
        $SelectedValue = [];

        # "Keyword" option is always here
        $Options["X-KEYWORD-X"] = "Keyword";
        $OptionClass["X-KEYWORD-X"] = "field-type-keyword";
        if ($FieldId == "X-KEYWORD-X") {
            $SelectedValue[] = "X-KEYWORD-X";
        }

        # prepare options for print
        foreach ($this->MFields as $MField) {
            if (!in_array($MField->schemaId(), $this->AllowedSchemaIds)) {
                continue;
            }

            $TypeName = defaulthtmlentities(
                str_replace(' ', '', strtolower($MField->TypeAsName()))
            );

            if (!$MField->Optional()) {
                $TypeName .= " required";
            }

            $FieldName = $MField->Name();
            if ($MField->SchemaId() != MetadataSchema::SCHEMAID_DEFAULT) {
                $FieldName = $this->AllSchemas[$MField->SchemaId()]->Name()
                           .": ".$FieldName;
            }

            $Options[$MField->Id()] = defaulthtmlentities($FieldName);
            $OptionClass[$MField->Id()] = "field-type-".$TypeName;

            if ($FieldId == $MField->Id()) {
                $SelectedValue[] = $MField->Id();
            }
        }

        # instantiate option list and print
        $OptList = new HtmlOptionList($ListName, $Options, $SelectedValue);
        $OptList->classForList("field-subject");
        $OptList->classForOptions($OptionClass);
        $OptList->maxLabelLength($this->MaxFieldLabelLength);
        $OptList->printHtml();
    }


    /**
    * Print HTML elements for the value selector for Option and Flag fields.
    * @param int|null $FieldId Currently selected FieldId.
    * @param string $CurVal Currently selected value.
    */
    private function printValueSelector($FieldId, string $CurVal)
    {
        # parameters of the option list
        $ListName = $this->EditFormName."[]";
        $Options = [];
        $OptionClass = [];
        $SelectedValue = [];

        # prepare options for print
        foreach ($this->MFields as $MField) {
            if (!in_array($MField->schemaId(), $this->AllowedSchemaIds)) {
                continue;
            }

            if ($MField->Type() == MetadataSchema::MDFTYPE_FLAG ||
                $MField->Type() == MetadataSchema::MDFTYPE_OPTION) {
                foreach ($MField->GetPossibleValues() as $Id => $Val) {
                    $IsSelected = false;
                    $Key = $MField->Id()."-".$Id;

                    if ($MField->Id() == $FieldId) {
                        $TgtVal = ($MField->Type() == MetadataSchema::MDFTYPE_FLAG) ?
                            "=".$Id : "=".$Val ;
                        $IsSelected = ($CurVal == $TgtVal) ? true : false ;
                    }

                    $Options[$Key] = defaulthtmlentities($Val);
                    $OptionClass[$Key] = "field-id-".$MField->Id();

                    if ($IsSelected) {
                        $SelectedValue[] = $Key;
                    }
                }
            }
        }

        # instantiate an option list and print
        $OptList = new HtmlOptionList($ListName, $Options, $SelectedValue);
        $OptList->classForList("field-value-select");
        $OptList->classForOptions($OptionClass);
        $OptList->maxLabelLength($this->MaxValueLabelLength);
        $OptList->printHtml();
    }

    /**
    * Output quicksearch field for ControlledName and Tree fields.
    * @param int|null $FieldId Currently selected FieldId.
    * @param string $CurVal Currently selected field value.
    */
    private function printQuicksearch($FieldId, string $CurVal)
    {
        if ($FieldId !== null && $FieldId != "X-KEYWORD-X") {
            if (!isset($this->Factories[$FieldId])) {
                $Field = new MetadataField($FieldId);
                $Factory = $Field->getFactory();

                $this->Factories[$FieldId] =
                    ($Factory !== null) ? $Factory : false;
            }

            $ItemId = ($this->Factories[$FieldId] !== false) ?
                $this->Factories[$FieldId]->getItemIdByName($CurVal) : "" ;
        } else {
            $ItemId = "";
        }

        QuickSearchHelper::printQuickSearchField(
            QuickSearchHelper::DYNAMIC_SEARCH,
            $ItemId,
            $CurVal,
            false,
            $this->EditFormName
        );
    }

    /**
    * Output template row for JS to copy when new fields are added.
    */
    private function printTemplateRow()
    {
        # field-row, template-row, field-value-edit, mv-speui-add, and
        # mv-speui-add-subgroup css classes required by our javascript
        print(
            "<tr class=\"field-row template-row ".$this->EditFormName."\""
                    ." style=\"white-space: nowrap;\">"
            ."<td>"
            ."<span class=\"btn btn-primary btn-sm "
            ."mv-speui-delete\">X</span>"
            ."</td><td>");
        $this->printFieldSelector(null);
        $this->printValueSelector(null, "");
        print("<input type=\"text\" class=\"field-value-edit\" "
              ."name=\"".$this->EditFormName."[]\" placeholder=\"(search terms)\" "
              ."value=\"\">");
        $this->printQuicksearch(null, "");
        print("</td></tr>");
        print("<tr><td colspan=2>"
              ."<span class=\"btn btn-primary btn-sm "
              ."mv-speui-add\">Add Field</span>"
              ."<span class=\"btn btn-primary btn-sm "
              ."mv-speui-add-subgroup\">Add Subgroup</span>"
              ."</td></tr>");
    }
}
